Skip to content

Phase 3: Make script rewriters fragment-safe for streaming#591

Open
aram356 wants to merge 6 commits intofeature/streaming-pipeline-phase2from
feature/streaming-pipeline-phase3
Open

Phase 3: Make script rewriters fragment-safe for streaming#591
aram356 wants to merge 6 commits intofeature/streaming-pipeline-phase2from
feature/streaming-pipeline-phase3

Conversation

@aram356
Copy link
Copy Markdown
Collaborator

@aram356 aram356 commented Mar 26, 2026

Summary

Make all script rewriters fragment-safe so streaming works even with GTM and NextJS active. This removes the buffered fallback introduced in Phase 1, enabling full streaming for all configurations. Also adds the 2xx streaming gate, publisher-level tests, and small-chunk pipeline regression tests.

Closes #586, closes #587, closes #588, closes #589, closes #590.
Part of epic #563. Depends on Phase 2 (#585).

Performance results (staging vs production, median over 5 runs, Chrome 1440x900)

Metric Production (v135, buffered) Staging (v136, streaming) Delta
TTFB 54 ms 35 ms -19 ms (-35%)
First Paint 186 ms 160 ms -26 ms (-14%)
First Contentful Paint 186 ms 160 ms -26 ms (-14%)
DOM Content Loaded 286 ms 282 ms -4 ms (~same)
DOM Complete 1060 ms 663 ms -397 ms (-37%)

Production (v135) buffers the entire response body before sending any bytes to the client. Staging (v136) streams processed chunks incrementally via StreamingBody. The 35% TTFB improvement cascades into earlier paint metrics, and DOM Complete sees the largest absolute gain (-397ms) because the browser can parse/render while still receiving the body.

Metric definitions
  • TTFB (Time to First Byte) — Time from the browser's navigation request until the first byte of the HTML response arrives. Measures server responsiveness.
  • First Paint (FP) — When the browser renders the first pixel to the screen (background color, border, etc.). Indicates the page is starting to load visually.
  • First Contentful Paint (FCP) — When the browser renders the first piece of actual content (text, image, canvas). Core Web Vital.
  • DOM Content Loaded — When the HTML has been fully parsed and all deferred scripts have executed.
  • DOM Complete — When the page and all sub-resources (images, stylesheets, iframes) have finished loading.

Problem

lol_html fragments text nodes across input chunk boundaries. When the streaming HtmlRewriterAdapter feeds chunks incrementally, a text node like "googletagmanager.com/gtm.js" can be split into "google" and "tagmanager.com/gtm.js" — neither fragment matches the full domain string, so the rewrite silently fails.

Phase 1 worked around this with a buffered adapter mode. Phase 3 fixes the root cause.

Solution

Each script rewriter now accumulates text fragments via Mutex<String> until last_in_text_node() is true, then processes the complete text:

  • Intermediate fragments: return RemoveNode (suppress output, accumulate)
  • Last fragment: process full accumulated text, return Replace(rewritten) or Keep
  • Unfragmented text (common case): no accumulation overhead, processes directly

What changed

File Lines What
script_rewriter.rs +106 -3 NextJsNextDataRewriter accumulates fragments
google_tag_manager.rs +143 -5 GoogleTagManagerIntegration accumulates fragments
streaming_processor.rs -97 Remove new_buffered(), buffered flag, accumulated_input, buffered test
html_processor.rs -7 Remove has_script_rewriters check, always use streaming adapter
publisher.rs +211 -5 2xx streaming gate, streaming gate tests, stream_publisher_body gzip test
nextjs/mod.rs +50 Small-chunk (32 byte) __NEXT_DATA__ pipeline regression test
google_tag_manager.rs +45 Small-chunk (32 byte) GTM pipeline regression test

Tests added

  • fragmented_next_data_is_accumulated_and_rewritten — splits __NEXT_DATA__ mid-URL
  • unfragmented_next_data_works_without_accumulation — fast path still works
  • fragmented_next_data_without_rewritable_urls_preserves_content — Keep-after-accumulation bug
  • fragmented_gtm_snippet_is_accumulated_and_rewritten — splits GTM domain mid-string
  • non_gtm_fragmented_script_is_passed_through — non-GTM scripts emitted unchanged
  • small_chunk_next_data_rewrite_survives_fragmentation — 32-byte chunks through full HTML pipeline
  • small_chunk_gtm_rewrite_survives_fragmentation — 32-byte chunks through full HTML pipeline
  • streaming_gate_allows_2xx_html_without_post_processors — gate unit test
  • streaming_gate_blocks_non_2xx_responses — 4xx/5xx stays buffered
  • streaming_gate_blocks_html_with_post_processors — post-processors force buffering
  • streaming_gate_allows_non_html_with_post_processors — non-HTML streams regardless
  • streaming_gate_blocks_non_2xx_json — error JSON stays buffered
  • stream_publisher_body_preserves_gzip_round_trip — public API gzip test

Verification

  • cargo test --workspace — 766 passed, 0 failed
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean
  • cargo fmt --all -- --check — clean
  • cargo build --release --target wasm32-wasip1 — success

Test plan

  • Fragmented NextJS __NEXT_DATA__ test passes
  • Fragmented GTM inline script test passes
  • Small-chunk (32 byte) pipeline tests pass for both integrations
  • All existing NextJS tests pass (57 tests)
  • All existing GTM tests pass (32 tests)
  • 2xx streaming gate unit tests pass
  • Gzip round-trip publisher test passes
  • Full workspace tests pass (766)
  • WASM build succeeds
  • Staging performance verified (35% TTFB improvement)

aram356 added 5 commits March 26, 2026 15:26
Accumulate text fragments via Mutex<String> until
last_in_text_node is true, then process the complete text.
Intermediate fragments return RemoveNode to suppress output.
Accumulate text fragments via Mutex<String> until
last_in_text_node is true, then match and rewrite on the complete
text. Non-GTM scripts that were fragmented are emitted unchanged.
All script rewriters (NextJS __NEXT_DATA__, GTM) are now
fragment-safe — they accumulate text internally until
last_in_text_node. The buffered adapter workaround is no longer
needed. Always use streaming mode in create_html_processor.
When rewrite_structured returns Keep on accumulated content,
intermediate fragments were already removed via RemoveNode. Emit
the full accumulated content via Replace to prevent silent data
loss. Also updates spec to reflect Phase 3 completion.
- Add response.get_status().is_success() check to streaming gate so
  4xx/5xx error pages stay buffered with complete status codes
- Add streaming gate unit tests covering all gate conditions
- Add stream_publisher_body gzip round-trip test
- Add small-chunk (32 byte) pipeline tests for __NEXT_DATA__ and GTM
  that prove fragmented text nodes survive the real lol_html path
Phase 3 performance results: 35% TTFB improvement, 37% DOM Complete
improvement on getpurpose.ai staging vs production. Phase 4 adds
binary pass-through streaming via PublisherResponse::PassThrough.
@aram356 aram356 marked this pull request as ready for review March 27, 2026 23:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment